上篇中,我們學習了 Django ORM 的Q
物件和 Django Ninja 的 FilterSchema,但後者感覺只學了一半。
討論比較多的是,view 函式中使用 FilterSchema 的參數定義方式——這確實很重要,但這只是 FilterSchema 的一部分。
本篇要來補完剩下的內容:
看來又是資訊滿滿的一篇,話不多說,直接開始吧!
本文所有的程式碼變動,可參考這個 PR。
還記得我們上一篇的程式碼實作嗎?
明明多定義了 FilterSchema,但 view 函式中的程式碼不僅沒有減少,反而還增加了!(雖然查詢邏輯也變多了,因為要同時查詢兩個欄位)
@router.get(...)
@paginate(CustomPagination)
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(), # 使用 FilterSchema
) -> QuerySet[Post]:
"""
取得文章列表
"""
posts = Post.objects.all()
if filters.query:
q = Q(title__icontains=filters.query) | \
Q(content__icontains=filters.query)
posts = posts.filter(q)
return posts
這簡直莫名其妙🐸
那是因為,FilterSchema 不是這麼用的!
我們應該盡可能將查詢邏輯封裝到 FilterSchema 中,這樣可以讓 view 函式更簡潔,並達到「關注點分離」的效果。
來看看更合理的寫法——將查詢邏輯遷移到 FilterSchema:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None,
q=['title__icontains', 'author__username__icontains'],
min_length=2,
max_length=10,
)
主要的變動是query
欄位的 Field 部分,現在加上了q=
參數內容:
q=["title__icontains", "author__name__icontains"]
很眼熟吧?沒錯,它們實際上就是Q
物件的條件語句,Django Ninja 會在背後自動調用Q
物件來執行這些查詢。
一旦使用q=
參數,Mypy 又會提醒你:
Unexpected keyword argument "q" for "Field"
它說的並沒有錯,因為 Pydantic Field 確實沒有這個參數——這是 Django Ninja 自行實作的。
你可以無視它,或加上必要的註解。
如此一來,view 函式只需要這樣寫就好了:
...
def get_posts(
request: HttpRequest,
filters: PostFilterSchema = Query(),
) -> QuerySet[Post]:
"""
取得文章列表
"""
posts = Post.objects.select_related('author')
posts = filters.filter(posts)
return posts
是不是簡單很多?
因為查詢邏輯從 view 函式「分離」出來了,這使得 view 函式的職責更單一、更利於維護。
新需求:除了可以查文章標題或作者名稱,現在還要加入對「發文日期」的過濾!
我們將引入兩個新的 URL 查詢參數:
start_date
end_date
兩者將用來查詢、過濾Post
模型中的created_at
欄位(即發文日期),以篩選特定時間範圍內的文章資料。
還有一個額外要求:兩者必須「全有全無」——可以都沒有,但不可以只填其中一個。
這是一個典型的「多欄位」查詢。
這是加入了上述邏輯後的 FilterSchema:
class PostFilterSchema(FilterSchema):
query: str | None = Field(
None, q=["title__icontains", "author__username__icontains"])
start_date: str | None = Field(None, q="created_at__gte")
end_date: str | None = Field(None, q="created_at__lte")
其中,start_date
和end_date
都是對模型欄位created_at
的查詢條件。
所以我們使用created_at__gte
和created_at__lte
來描述過濾邏輯(它們都對應了各自的Q
物件),以篩選出符合條件的資料。
那 view 函式呢?你猜得沒錯——完全不用動!
這就是使用 FilterSchema 的好處。
有趣的是,當我試著為這些查詢參數加上文件範例時,如果這樣寫:
start_date: str | None = Field(
None, q='created_at__gte', examples=['2021-01-01']
查看 API 文件將會得到:
😱 Could not render Parameters, see the console.
但寫example='2021-01-01'
卻可以成功。
這可能是 Django Ninja 與 Pydantic 在整合上的一個 bug,我們暫且就先略過吧!
當我們要查詢某段時間內的文章時,可以使用以下的 URL 查詢參數:
?start_date=2023-01-01&end_date=2023-01-31
這樣就能輕鬆查詢出 2023 年 1 月份的所有文章。
附帶一提,日期中的時間因為沒有指定,預設上都是 0 點 0 分 0 秒。所以如果填同一天,就查不到任何東西。
這是一個需要改善或重新調整的細節,常見的做法是在程式內部把end_date
加 1 天,而我直接選擇讓兩者不能相同XD。實際該怎麼做,取決於你的需求。
除了單純的期間查詢,我們也可以查詢某作者在某段時間內的文章,以查詢作者 Alice 為例:
?start_date=2023-01-01&end_date=2023-01-31&query=alice
結果顯示了 Alice 在 2023 年 1 月份的所有文章。
這部分一定要特別介紹,依文件所述,預設上:
OR
operator.AND
operator.意思就是說,單一欄位內的多個 Q 語句,彼此是 OR 關係,比如上面query
的:
q=['title__icontains', 'author__username__icontains']
可以查詢文章標題「或」作者名稱。
而不同欄位中的條件(如果都有),則是 AND 關係——必須同時符合才行。所以作者名稱與日期區間,兩者的條件必須同時符合。
這些預設邏輯可以自行變更,詳情請參考上述文件內容。
本例中,除了欄位查詢,我們還要確保,使用者輸入的開始日期必須早於結束日期。
並且,兩個欄位的查詢值必須是「全有」或「全無」(全無則不必驗證)。
這個需求非常眼熟——不就是第 20 篇提到的「跨欄位驗證」嗎?
沒錯,我們要透過 Pydantic 的model_validator
來實現,它允許我們在驗證過程中,對輸入資料進行自定義的邏輯檢查。
程式碼有點多,我們直接看重點:
class PostFilterSchema(FilterSchema):
...
start_date: str | None = Field(None, q='created_at__gte')
end_date: str | None = Field(None, q='created_at__lte')
@model_validator(mode='after')
def check_date_range(self) -> Self:
# 如果開始日期和結束日期都是 None,則不進行任何檢查
if self.start_date is None and self.end_date is None:
return self
if not all([self.start_date, self.end_date]):
raise ValueError('開始日期和結束日期必須同時提供或同時不提供')
try:
start_date_dt = datetime.strptime(self.start_date, '%Y-%m-%d')
end_date_dt = datetime.strptime(self.end_date, '%Y-%m-%d')
except ValueError:
raise ValueError('日期格式無效,應為 YYYY-MM-DD')
if start_date_dt > end_date_dt:
raise ValueError('開始日期必須早於結束日期')
return self
對於查詢條件,我們使用 Pydantic 的model_validator
進行跨欄位驗證,確保使用者輸入的日期是有效且合理的。
事實上,跨欄位驗證往往要考慮很多細節,否則可能掛一漏萬,間接產生新的 bug。
這個例子就是一個典型案例。
我們必須全盤考慮各種可能的輸入情況,包括日期格式是否正確、日期範圍是否合理,以及兩個日期欄位是否同時存在或同時為空。
細緻的驗證邏輯能提升 API 的可靠性,避免因為無效或不合理的輸入而導致系統出現意外行為。而粗糙的邏輯則反之。
PS:這裡的拋出錯誤,範例程式碼中仍使用了ValueError
,我並沒有變更為 Django 的ValidationError
。(最新版已修正)
但以下回應則是模擬 Django 的ValidationError
,以減少不必要的重複。
一、只輸入開始日期:(這發生機率不高,因為前端通常會限制)
{
"detail": "開始日期和結束日期必須同時提供或同時不提供"
}
二、輸入不合法的日期,比如2023-02-30
:
{
"detail": "日期格式無效,應為 YYYY-MM-DD"
}
三、輸入不合法的日期區間,比如start_date=2023-01-31&end_date=2023-01-01
:
{
"detail": "開始日期必須早於結束日期"
}
這兩篇文章,我們介紹了 FilterSchema 的有效用法,完成了多欄位查詢和日期篩選,並示範如何使用model_validator
來強化資料驗證,確保查詢邏輯的正確性。
我們還看了這些應用場景的實例程式碼,幫助讀者更好地理解每個步驟的用途和效果。
下一章,我們將探討 Django Ninja 中的認證(Authentication)機制,並介紹如何使用 pytest 進行單元測試,這些都是後端開發中,不可或缺的要素。
本文同步發表於我的部落格——Code and Me